Skip to content

perf(clients): fix composer draft persistence boundaries - #9049

Open
StiensWout wants to merge 4 commits into
pingdotgg:mainfrom
StiensWout:t3code/composer-draft-persistence
Open

perf(clients): fix composer draft persistence boundaries#9049
StiensWout wants to merge 4 commits into
pingdotgg:mainfrom
StiensWout:t3code/composer-draft-persistence

Conversation

@StiensWout

@StiensWout StiensWout commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Typing next to a heavy composer draft was paying for the whole draft store on every keystroke, and mobile image drafts were storing megabytes of base64 in JSON. This is item 03 of the performance opportunity audit.

Web: the persist pipeline ran the full draft walk plus JSON.stringify on every store write, so each keystroke serialized every persisted draft, including base64 image attachments; only the final localStorage.setItem was debounced. partialize now captures the live state reference cheaply, and the real walk + stringify run once per idle flush inside the debounced storage (createDeferredStorage). Flush-on-unload, flush-before-attachment-verify, migration writeback, and hydration behavior are unchanged.

Mobile: picked and pasted images persisted full base64 data URLs into drafts.json, thread-outbox/*.json, and incoming-shares/*.json, re-serialized on every 200 ms draft debounce and re-parsed at cold start. Images are now file-backed the way file attachments already were: bytes are copied once into the app-owned attachment directory and drafts persist metadata plus fileUri. Uploads stream from the owned file (no temp base64 staging), and base64 is materialized lazily only for the old-server inline fallback. Legacy dataUrl drafts still hydrate, preview, upload, and send; the outbox schema version moves 3 → 4 following the existing pattern.

Measurements

Metric Before After
Web: full draft-store serializations per 200 keystrokes 200 1
Web: bytes serialized during those keystrokes 534.6 MB 2.7 MB
Web: main-thread time in the typing loop 1693.5 ms 6.8 ms
Mobile: persisted draft JSON for one 8 MB image 21.4 MB (22,369,796 B) 322 B

Web numbers are from a store seeded with 20 drafts, one holding two 1 MB persisted images, driving 200 setPrompt calls through the real persist pipeline. Mobile numbers encode one attachment through the actual draft schema (the legacy shape stored the data URL twice: dataUrl + previewUri).

Draft persistence still works (type → reload → restored)

Composer draft typed, page reloaded, draft restored

Recorded against a live dev environment running this branch: the draft is typed, flushed after idle, and restored into the composer and sidebar after a full page reload.

Verification

  • vp test run across all 12 touched suites: 333 tests pass (web draft store 105, mobile attachment/draft/outbox/share suites 228).
  • Scoped typecheck (tsgo --noEmit for web, tsc --noEmit for mobile) and vp lint on changed files: clean.
  • Web verified end to end in a real browser (typing, idle flush to localStorage, reload restore). Mobile verified through tests and typecheck only; no simulator run on this Linux workbench.

Known limitation: downgrading the mobile app to a pre-change build will not decode fileUri-only drafts or v4 outbox entries, the same class of impact as the earlier file-attachment rollout.

Change made by Claude Fable 5 running in Claude Code.


Note

Medium Risk
Broad changes to mobile attachment lifecycle, outbox schema v4, and async image upload wiring; legacy paths are preserved but downgrading the mobile app will not read file-only drafts.

Overview
Web: Composer draft persistence no longer runs the full draft walk and JSON.stringify on every keystroke. partialize only captures a live state reference; createDeferredStorage runs normalization and serialization once per debounced flush (flush-on-unload unchanged).

Mobile: New image attachments (picker, clipboard, paste, incoming share) are stored as app-owned files with fileUri metadata instead of megabyte-scale dataUrl in drafts, thread outbox (schema v3 → v4), and share drafts. Uploads stream from the owned file; base64 is read lazily only for servers without image uploads. Legacy inline dataUrl drafts still decode and send. Image previews rebase paths when the iOS document container moves; review composer cleans up unreferenced files on dismiss/remove.

Send path: buildProjectThreadStartTurnInput now always uses prepared uploadedAttachments (raw draft attachments removed from project start/outbox drain).

Reviewed by Cursor Bugbot for commit 0c1f781. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Switch composer image attachments to app-owned files

  • Replaces inline base64 dataUrl image storage with app-owned file paths (fileUri) across composer drafts, outbox, and incoming shares.
  • Adds FileBackedComposerAttachment type and isFileBackedComposerAttachment guard to narrow attachments with a defined fileUri.
  • toUploadChatImageAttachments is now async and lazily reads base64 from disk only when the server lacks image uploads.
  • uploadFileBytes uploads directly from fileUri and only stages temporary files for legacy inline images.
  • Web composer draft persistence uses createDeferredStorage with deferred serialization; partializeComposerDraftStoreState is now exported.
  • Bumps thread outbox schema to v4; accepts v1-v4 on decode.
  • Behavioral Change: schema accepts dataUrl or fileUri; old drafts without fileUri still work via legacy inline path. Drafts/outbox now persist image paths instead of bytes.

Macroscope summarized 0c1f781.

Web: the zustand persist pipeline ran the full draft walk plus
JSON.stringify on every store write, so each keystroke serialized every
persisted draft, including base64 image attachments; only the final
localStorage write was debounced. partialize now captures the live state
cheaply and the walk + stringify run once per idle flush inside the
debounced storage. 200 keystrokes next to two 1 MB images: 200
serializations / 534.6 MB / 1693.5 ms of main-thread time down to
1 / 2.7 MB / 6.8 ms.

Mobile: picked and pasted images persisted full base64 data URLs into
drafts.json, thread-outbox/*.json, and incoming-shares/*.json (an 8 MB
image cost 21.3 MB of JSON per copy, rewritten on every 200 ms draft
debounce). Images are now file-backed like file attachments already
were: bytes are copied once into the app-owned attachment directory and
drafts persist metadata plus fileUri (322 bytes). Uploads stream from
the owned file; base64 is materialized lazily only for the old-server
inline fallback. Legacy dataUrl drafts still hydrate, preview, upload,
and send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment thread apps/mobile/src/lib/composerImages.ts Outdated
name: input.name,
mimeType: input.mimeType,
sizeBytes,
fileUri,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium lib/composerImages.ts:290

Review-comment images leave their copied files in t3-composer-attachments permanently when removed or when the sheet is dismissed, steadily consuming device storage. createComposerImageAttachment returns an app-owned fileUri, but the review-comment composer only drops the attachment from state instead of calling removePersistedComposerAttachmentFile; invoke that cleanup path for both removal and dismissal.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/lib/composerImages.ts around line 290:

Review-comment images leave their copied files in `t3-composer-attachments` permanently when removed or when the sheet is dismissed, steadily consuming device storage. `createComposerImageAttachment` returns an app-owned `fileUri`, but the review-comment composer only drops the attachment from state instead of calling `removePersistedComposerAttachmentFile`; invoke that cleanup path for both removal and dismissal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[claude-fable-5] RESPONDING ON BEHALF OF WOUT:

Confirmed and fixed in afe1617. The sheet's local attachments now go through the reference-checked cleanup: removing an attachment releases its copied file immediately, and any attachments still held when the sheet unmounts (dismissal by cancel, back, or gesture) are released there. Submitted attachments are appended to the thread draft before the sheet closes, so the reference check keeps their files.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

The review-comment sheet holds picked images in local state until submit
copies them into the thread draft. With file-backed images, removing an
attachment or dismissing the sheet dropped the state while the copied
file stayed in the attachment directory. Removal and unmount now route
through the reference-checked cleanup, so submitted files stay owned by
the draft and abandoned ones are deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@macroscopeapp

macroscopeapp Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is an XL cross-platform persistence and attachment-lifecycle migration, not a small performance-only change: it adds app-owned image files, asynchronous upload fallbacks, outbox v4 handling, HEIC conversion, and deferred web serialization. The unresolved medium finding concerns cleanup of abandoned review-comment files, while downgrade behavior also changes, so the storage lifecycle and compatibility boundaries need human verification.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit afe1617. Configure here.

Comment thread apps/mobile/src/lib/composerImages.ts
Dropping the picker's base64 export also dropped the only source of
provider-supported bytes for HEIC-family originals: the picker's file
copy stays HEIC on both its fast and slow paths, so those photos were
rejected as unsupported. The picker exports JPEG base64 again, used only
as the fallback for unsupported originals and landed once in the owned
attachment directory; supported formats still copy their original file
and nothing base64 is persisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 1, 2026
Comment thread apps/mobile/src/lib/composerImages.ts
iOS always transcodes the base64 export to JPEG, but Android's quality-1
export is the raw original, so an Android HEIC pick would have shipped
HEIC bytes labeled image/jpeg. The fallback now requires the JPEG magic
number in the export and otherwise rejects the photo as unsupported,
matching the pre-change Android behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
t3dotgg added a commit that referenced this pull request Sep 4, 2026
Defer the draft walk and JSON serialization until the storage write flushes.
Preserve hydration, migrations, attachment verification, and final flushes.

Continues the web portion of [#9049](#9049).
The mobile storage migration remains separate.

Created with GPT-6 Astra (preview) in Codex.

Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant